本文將介紹如何使用 JWT 保持登入狀態,配合 Swagger / OpenAPI 呈現。
建立一個類別 JwtAuthUtil.cs,負責 token 生成相關功能
using Jose;
using System;
using System.Collections.Generic;
using System.Text;
using System.Web.Configuration;
using MyWebApiProject.Models;
namespace MyWebApiProject.Security
{
	/// <summary>
    /// JwtToken 生成功能
    /// </summary>
    public class JwtAuthUtil
    {
        private readonly ApplicationDbContext db = new ApplicationDbContext(); // DB 連線
        /// <summary>
        /// 生成 JwtToken
        /// </summary>
        /// <param name="id">會員id</param>
        /// <returns>JwtToken</returns>
        public string GenerateToken(int id)
        {
			// 自訂字串,驗證用,用來加密送出的 key (放在 Web.config 的 appSettings)
            string secretKey = WebConfigurationManager.AppSettings["TokenKey"]; // 從 appSettings 取出
            var user = db.User.Find(id); // 進 DB 取出想要夾帶的基本資料
			// payload 需透過 token 傳遞的資料 (可夾帶常用且不重要的資料)
            var payload = new Dictionary<string, object>
            {
                { "Id", user.Id },
                { "Account", user.Account },
                { "NickName", user.NickName },
                { "Image", user.Image },
                { "Exp", DateTime.Now.AddMinutes(30).ToString() } // JwtToken 時效設定 30 分
            };
			// 產生 JwtToken
            var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512);
            return token;
        }
        /// <summary>
        /// 生成只刷新效期的 JwtToken
        /// </summary>
        /// <returns>JwtToken</returns>
        public string ExpRefreshToken(Dictionary<string, object> tokenData)
        {
            string secretKey = WebConfigurationManager.AppSettings["TokenKey"];
			// payload 從原本 token 傳遞的資料沿用,並刷新效期
            var payload = new Dictionary<string, object>
            {
                { "Id", (int)tokenData["Id"] },
                { "Account", tokenData["Account"].ToString() },
                { "NickName", tokenData["NickName"].ToString() },
                { "Image", tokenData["Image"].ToString() },
                { "Exp", DateTime.Now.AddMinutes(30).ToString() } // JwtToken 時效刷新設定 30 分
            };
			//產生刷新時效的 JwtToken
            var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512);
            return token;
        }
        /// <summary>
        /// 生成無效 JwtToken
        /// </summary>
        /// <returns>JwtToken</returns>
        public string RevokeToken()
        {
            string secretKey = "RevokeToken"; // 故意用不同的 key 生成
            var payload = new Dictionary<string, object>
            {
                { "Id", 0 },
                { "Account", "None" },
                { "NickName", "None" },
                { "Image", "None" },
                { "Exp", DateTime.Now.AddDays(-15).ToString() } // 使 JwtToken 過期 失效
            };
			// 產生失效的 JwtToken
            var token = JWT.Encode(payload, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512);
            return token;
        }
    }
}
在登入功能的 API 確認登入成功後,生成 JwtToken 並回傳給前端
// GenerateToken() 生成新 JwtToken 用法
JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
string jwtToken = jwtAuthUtil.GenerateToken(userQuery.Id); 
// 登入成功時,回傳登入成功順便夾帶 JwtToken
return Ok(new { Status = true, JwtToken = jwtToken });
前端將收到的 JWT-Token 字串存入瀏覽器 localStorage

建立一個類別 JwtAuthFilter.cs,負責生成 [JwtAuthFilter] 標籤,可放於需登入的 API 上,用來檢核 JWT-Token 是否正確
using Jose;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Web.Configuration;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Filters;
namespace MyWebApiProject.Security
{
    /// <summary>
    /// JwtAuthFilter 繼承 ActionFilterAttribute 可生成 [JwtAuthFilter] 使用
    /// </summary>
    public class JwtAuthFilter : ActionFilterAttribute
    {
        // 加解密的 key,如果不一樣會無法成功解密
        private static readonly string secretKey = WebConfigurationManager.AppSettings["TokenKey"];
        /// <summary>
        /// 過濾有用標籤 [JwtAuthFilter] 請求的 API 的 JwtToken 狀態及內容
        /// </summary>
        /// <param name="actionContext"></param>
        public override void OnActionExecuting(HttpActionContext actionContext)
        {
            // 取出請求內容並排除不需要驗證的 API
            var request = actionContext.Request;
            if (!WithoutVerifyToken(request.RequestUri.ToString())) {
                // 有取到 JwtToken 後,判斷授權格式不存在且不正確時
                if (request.Headers.Authorization == null || request.Headers.Authorization.Scheme != "Bearer") {
                    // 可考慮配合前端專案開發期限,不修改 StatusCode 預設 200,將請求失敗搭配 Status: false 供前端判斷
                    string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "請重新登入" }); // JwtToken 遺失,需導引重新登入
                    var errorMessage = new HttpResponseMessage()
                    {
                        // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401
                        ReasonPhrase = "JwtToken Lost",
                        Content = new StringContent(messageJson,
                                    Encoding.UTF8,
                                    "application/json")
                    };
                    throw new HttpResponseException(errorMessage); // Debug 模式會停在此行,點繼續執行即可
                }
                else {
                    try {
                        // 有 JwtToken 且授權格式正確時執行,用 try 包住,因為如果有篡改可能解密失敗
                        // 解密後會回傳 Json 格式的物件 (即加密前的資料)
                        var jwtObject = GetToken(request.Headers.Authorization.Parameter);
                        // 檢查有效期限是否過期,如 JwtToken 過期,需導引重新登入
                        if (IsTokenExpired(jwtObject["Exp"].ToString())) {
                            string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "請重新登入" }); // JwtToken 過期,需導引重新登入
                            var errorMessage = new HttpResponseMessage()
                            {
                                // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401
                                ReasonPhrase = "JwtToken Expired",
                                Content = new StringContent(messageJson,
                                    Encoding.UTF8,
                                    "application/json")
                            };
                            throw new HttpResponseException(errorMessage); // Debug 模式會停在此行,點繼續執行即可
                        }
                    }
                    catch (Exception) {
                        // 解密失敗
                        string messageJson = JsonConvert.SerializeObject(new { Status = false, Message = "請重新登入" }); // JwtToken 不符,需導引重新登入
                        var errorMessage = new HttpResponseMessage()
                        {
                            // StatusCode = System.Net.HttpStatusCode.Unauthorized, // 401
                            ReasonPhrase = "JwtToken NotMatch",
                            Content = new StringContent(messageJson,
                                    Encoding.UTF8,
                                    "application/json")
                        };
                        throw new HttpResponseException(errorMessage); // Debug 模式會停在此行,點繼續執行即可
                    }
                }
            }
            base.OnActionExecuting(actionContext);
        }
        /// <summary>
        /// 將 Token 解密取得夾帶的資料
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        public static Dictionary<string, object> GetToken(string token)
        {
            return JWT.Decode<Dictionary<string, object>>(token, Encoding.UTF8.GetBytes(secretKey), JwsAlgorithm.HS512);
        }
        /// <summary>
        /// 有在 Global 設定一律檢查 JwtToken 時才需設定排除,例如 Login 不需要驗證因為還沒有 token
        /// </summary>
        /// <param name="requestUri"></param>
        /// <returns></returns>
        public bool WithoutVerifyToken(string requestUri)
        {
            //if (requestUri.EndsWith("/login")) return true;
            return false;
        }
        /// <summary>
        /// 驗證 token 時效
        /// </summary>
        /// <param name="dateTime"></param>
        /// <returns></returns>
        public bool IsTokenExpired(string dateTime)
        {
            return Convert.ToDateTime(dateTime) < DateTime.Now;
        }
    }
}
使用者用到需登入的 API 時,前端取出瀏覽器 localStorage 中的 JWT-Token 字串用於請求的 Header 裡 Authorization 欄位用 Bearer 規則夾帶
將 [JwtAuthFilter] 標籤,放於需登入的 API 上,檢核 JWT-Token 是否正確,並刷新效期
// 取出請求內容,解密 JwtToken 取出資料
var userToken = JwtAuthFilter.GetToken(Request.Headers.Authorization.Parameter);
// ExpRefreshToken() 生成刷新效期 JwtToken 用法
JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
string jwtToken = jwtAuthUtil.ExpRefreshToken(userToken);
// Do Something ~
// 處理完請求內容後,順便送出刷新效期的 JwtToken
return Ok(new { Status = true, JwtToken = jwtToken });
用於強制登出使用者的方式
// RevokeToken() 生成失效的 JwtToken 用法
JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
string jwtToken = jwtAuthUtil.RevokeToken();
// 用於登出使用者時,刷新為失效的 JwtToken
return Ok(new { Status = true, jwtToken = jwtToken });
在專案新增 OWIN 啟動類別 Startup.cs,並加入相關設定

using Microsoft.Owin;
using NSwag;
using NSwag.AspNet.Owin;
using NSwag.Generation.Processors.Security;
using Owin;
using System.Web.Http;
[assembly: OwinStartup(typeof(MyWebApiProject.Startup))]
namespace MyWebApiProject
{
    /// <summary>
    /// OWIN 啟動類別
    /// </summary>
    public class Startup
    {
        /// <summary>
        /// 應用程式配置
        /// </summary>
        /// <param name="app"></param>
        public void Configuration(IAppBuilder app)
        {
            // 如需如何設定應用程式的詳細資訊,請瀏覽 https://go.microsoft.com/fwlink/?LinkID=316888
            var config = new HttpConfiguration();
            // 針對 JSON 資料使用 camel (JSON 回應會改 camel,但 Swagger 提示不會)
            //config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
            app.UseSwaggerUi3(typeof(Startup).Assembly, settings =>
            {
                // 針對 WebAPI,指定路由包含 Action 名稱
                settings.GeneratorSettings.DefaultUrlTemplate =
                    "api/{controller}/{action}/{id?}";
                // 加入客製化調整邏輯名稱版本等
                settings.PostProcess = document =>
                {
                    document.Info.Title = "WebAPI : 專案名稱";
                };
                // 加入 Authorization JWT token 定義
                settings.GeneratorSettings.DocumentProcessors.Add(new SecurityDefinitionAppender("Bearer", new OpenApiSecurityScheme()
                {
                    Type = OpenApiSecuritySchemeType.ApiKey,
                    Name = "Authorization",
                    Description = "Type into the textbox: Bearer {your JWT token}.",
                    In = OpenApiSecurityApiKeyLocation.Header,
                    Scheme = "Bearer" // 不填寫會影響 Filter 判斷錯誤
                }));
                // REF: https://github.com/RicoSuter/NSwag/issues/1304 (每支 API 單獨呈現認證 UI 圖示)
                settings.GeneratorSettings.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer"));
            });
            app.UseWebApi(config);
            config.MapHttpAttributeRoutes();
            config.EnsureInitialized();
        }
    }
}
於專案屬性的建置內容勾選輸出 XML 文件檔案, Swagger UI 才會有方法及參數說明

於 Web.config 加入處理器設定,將 URL /swagger/* 導向 NSwag 處理程式
<configuration>
	<system.webServer>
		<handlers>
			<add name="NSwag" path="swagger" verb="*" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" />
		</handlers>
	</system.webServer>
</configuration>
於 API 及輸入欄位加上 /// 撰寫 XML 方法及參數說明
使用登入 API 取得回傳的 JWT token 於 Swagger UI 的 Authorization JWT token 輸入驗證時,使用 Bearer + 空格 + JWT token 夾帶

可於 ApiController 上加入 [OpenApiTag] 描述整個模組功能
[OpenApiTag("Users", Description = "使用者操作功能")]
public class UsersController : ApiController
{
	/// <summary>
    /// 1-5 聯絡我們功能 (JWT)
    /// </summary>
    /// <param name="contactUsVm">留言資料</param>
    /// <returns></returns>
	/// <summary>
    [JwtAuthFilter] // 用於檢核 JWT-Token 
    [HttpPost]
    [SwaggerResponse(typeof(ApiResult))] // 顯示回傳資料的註解
    [Route("api/users/contact-us")]
    public IHttpActionResult SendContactUsMail(ContactUsVm contactUsVm)
    {
        // Do Sometning
        // 取出請求內容,解密 JwtToken 取出資料
        var userToken = JwtAuthFilter.GetToken(Request.Headers.Authorization.Parameter);
        //單純刷新效期不新生成,新生成會進資料庫
        JwtAuthUtil jwtAuthUtil = new JwtAuthUtil();
        string jwtToken = jwtAuthUtil.ExpRefreshToken(userToken);
        // 送出刷新 JwtToken
        return Ok(new ApiResult { Status = true, JwtToken = jwtToken });
	}
}

由於使用 OWIN 啟動會改成由 Startup.cs 類別管理,因此需將放行的請求類型及跨域操作加入。
刪除原 Web API 2 官方建議的作法,於 NuGet 移除 Microsoft.AspNet.WebApi.Cors
於 Startup.cs 設定最上方加入啟用跨域及驗證
using Microsoft.Owin;
using Microsoft.Owin.Security.OAuth;
using NSwag;
using NSwag.AspNet.Owin;
using NSwag.Generation.Processors.Security;
using Owin;
using System.Web.Http;
using Thak_tshehWebAPI.Security;
[assembly: OwinStartup(typeof(MyWebApiProject.Startup))]
namespace MyWebApiProject
{
    /// <summary>
    /// OWIN 啟動類別
    /// </summary>
    public class Startup
    {
        /// <summary>
        /// 應用程式配置
        /// </summary>
        /// <param name="app"></param>
        public void Configuration(IAppBuilder app)
        {
            // 啟用跨域及驗證配置
            ConfigureAuth(app);
            var config = new HttpConfiguration();
            // 針對 JSON 資料使用 camel (JSON 回應會改 camel,但 Swagger 提示不會)
            //config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
            app.UseSwaggerUi3(typeof(Startup).Assembly, settings =>
            {
                // 針對 WebAPI,指定路由包含 Action 名稱
                settings.GeneratorSettings.DefaultUrlTemplate =
                    "api/{controller}/{action}/{id?}";
                // 加入客製化調整邏輯名稱版本等
                settings.PostProcess = document =>
                {
                    document.Info.Title = "WebAPI : 專案名稱";
                };
                // 加入 Authorization JWT token 定義
                settings.GeneratorSettings.DocumentProcessors.Add(new SecurityDefinitionAppender("Bearer", new OpenApiSecurityScheme()
                {
                    Type = OpenApiSecuritySchemeType.ApiKey,
                    Name = "Authorization",
                    Description = "Type into the textbox: Bearer {your JWT token}.",
                    In = OpenApiSecurityApiKeyLocation.Header,
                    Scheme = "Bearer" // 不填寫會影響 Filter 判斷錯誤
                }));
                // REF: https://github.com/RicoSuter/NSwag/issues/1304 (每支 API 單獨呈現認證 UI 圖示)
                settings.GeneratorSettings.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer"));
            });
            app.UseWebApi(config);
            config.MapHttpAttributeRoutes();
            config.EnsureInitialized();
        }
        /// <summary>
        /// 啟用跨域及驗證配置
        /// </summary>
        /// <param name="app"></param>
        private void ConfigureAuth(IAppBuilder app)
        {
            // 建立 OAuth 配置
            var oAuthOptions = new OAuthAuthorizationServerOptions
            {
                Provider = new AuthorizationServerProvider()
            };
            // 啟用 OAuth2 bearer tokens 驗證並加入配置
            app.UseOAuthAuthorizationServer(oAuthOptions);
        }
    }
}
新增 AuthorizationServerProvider.cs 類別檔,設定跨域 Request Headers 處理邏輯
using Microsoft.Owin;
using Microsoft.Owin.Security.OAuth;
using System;
using System.Configuration;
using System.Linq;
using System.Threading.Tasks;
namespace MyWebApiProject.Security
{
    /// <summary>
    /// OAuth 配置並繼承 OAuthAuthorizationServerProvider
    /// </summary>
    public class AuthorizationServerProvider : OAuthAuthorizationServerProvider
    {
        /// <summary>
        /// 在驗證客戶端身分前調用,並依客戶端請求來源配置 CORS 允許類型設定
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task MatchEndpoint(OAuthMatchEndpointContext context)
        {
            // 依請求來源配置 CORS 允許類型設定
            SetCORSPolicy(context.OwinContext);
            // 如果請求為預檢請求則設為完成直接回傳
            if (context.Request.Method == "OPTIONS") {
                context.RequestCompleted();
                return Task.FromResult(0);
            }
            return base.MatchEndpoint(context);
        }
        /// <summary>
        /// 依請求來源配置 CORS 允許類型設定
        /// </summary>
        /// <param name="context"></param>
        private void SetCORSPolicy(IOwinContext context)
        {
            // 取出允許跨域的網址 (放在 Web.config 的 appSettings)
            string allowedUrls = ConfigurationManager.AppSettings["allowedOrigins"];
            // 有填寫允許跨域的網址,就分割取出判斷請求的來源是否等於允許跨域的網址,並將允許網址加入 Headers
            if (!String.IsNullOrWhiteSpace(allowedUrls)) {
                var list = allowedUrls.Split(',');
                if (list.Length > 0) {
                    string origin = context.Request.Headers.Get("Origin");
                    var found = list.Where(item => item == origin).Any();
                    if (found) {
                        context.Response.Headers.Add("Access-Control-Allow-Origin",
                                                     new string[] { origin });
                    }
                }
            }
            // 配置允許請求的 Headers 內容
            context.Response.Headers.Add("Access-Control-Allow-Headers",
                                   new string[] { "Authorization", "Content-Type" });
            // 配置允許請求的 Headers 方法
            context.Response.Headers.Add("Access-Control-Allow-Methods",
                                   new string[] { "OPTIONS", "GET", "POST", "PUT", "DELETE"});
        }
    }
}
於 Web.config 加入允許前端跨域請求的網址 (若無需求可不填)
<configuration>
  <appSettings>
	  <!--Owin CORS-->
	  <add key="allowedOrigins" value="https://www.myfriendproject.com,https://localhost:44444,http://127.0.0.1:5500" />
  </appSettings>
</configuration>
[OpenApiTag("Users", Description = "使用者操作功能")]
public class UsersController : ApiController
{
    /// <summary>
    /// 1-5 聯絡我們功能 (JWT)
    /// </summary>
    /// <param name="contactUsVm">留言資料</param>
    /// <returns></returns>
    /// <summary>
    [JwtAuthFilter] // 用於檢核 JWT-Token 
    [HttpPost]
    [SwaggerResponse(typeof(ApiResult))] // 顯示回傳資料的註解
    [Route("api/users/contact-us")]
    public IHttpActionResult SendContactUsMail([FromUri] ContactUsVm contactUsVm)
    {
        // Do Sometning
    }
}
